Skip to content

feat(mcp): base-to-base sync, idle park Chromium, multiSelect updates - #19

Open
ARHAEEM wants to merge 246 commits into
mainfrom
feat/base-to-base-sync
Open

feat(mcp): base-to-base sync, idle park Chromium, multiSelect updates#19
ARHAEEM wants to merge 246 commits into
mainfrom
feat/base-to-base-sync

Conversation

@ARHAEEM

@ARHAEEM ARHAEEM commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

Adds the base-to-base schema/records sync engine (sync_base plan/apply/status/diff/reconcile) and hardens the MCP daemon lifecycle so long sync jobs can run safely on Windows.

Sync engine (M1–M3+)

  • Plan/apply/diff/reconcile for schema + records (background jobs, drift guard, journal resume)
  • Record sync: create/update, links, attachments, view filters, policy presets
  • MultiSelect / multiCollaborator updates on existing records via add/remove item APIs (no longer permanently RECORD_ARRAY_UPDATE_DEFERRED)

Daemon / auth lifecycle (this workstream tip)

  • PageScheduler: exclusive queue + barrier for park/close/init
  • Idle park: free Chromium RSS; cookie-only HTTP continues (AIRTABLE_BROWSER_IDLE_PARK_MS / browserIdleParkMinutes)
  • Process-tree: chrome/msedge-scoped squatter eviction + ownership guard
  • Tool labels: runInToolContext on CallTool; extension sends x-airtable-client-id
  • pageBusy: /daemon/health uses the same AirtableAuth as tools (shared scheduler)

How to test

  1. Restart the MCP daemon so shared-auth + multiSelect code is loaded.
  2. /daemon/health while calling a tool → pageBusy.active.tool / clientId populated.
  3. Idle park: set short park minutes, wait, confirm browser released and session still valid.
  4. Sync smoke (disposable bases):
    • Source appMDjopjzDGoyLHD → dest appWDcNnIgTpZcHBW (or your own pair)
    • sync_base mode=plan then mode=apply with policy=overlay
    • Change a multiSelect on an existing mapped record; re-plan/apply → dest converges, no RECORD_ARRAY_UPDATE_DEFERRED
  5. Unit: node --test test/test-page-scheduler.test.js test/test-auth-idle-park.test.js test/test-process-tree.test.js test/sync/test-array-choice-update.test.js test/test-daemon-server.test.js

Live smoke (2026-07-22)

  • Plan + apply on new-account bases: pass
  • pageBusy tools: get_base_schema, sync_base; clientId: vscode-airtable-formula
  • multiSelect UPDATE path: updated: 1, arrayDefer: 0

Related

  • Branch workstream: base-to-base sync + daemon lifecycle hardening
  • Tip commits: d3204b1 (idle park/scheduler), ae481e1 (pageBusy shared auth + multiSelect updates)

Checklist

  • Tests included for scheduler, idle park, process-tree, array updates, shared pageBusy
  • Live smoke on disposable bases
  • Docs/changelog bump if releasing from this PR

ARHAEEM and others added 30 commits June 15, 2026 22:43
Wrap each row in try/catch so assertAirtableId throws isolate to failed
instead of aborting the batch. Empty cellValuesByColumnId now reports as
failed instead of silently landing in updated with zero API calls made.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire the three existing client methods as MCP tools in the record-write
category. Tool definitions, handlers, category entries, and test count
updates (66→69) are all included.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rd-tool docs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (full-only)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces packages/mcp-server/src/sync/snapshot.js — the first module
of the M2a schema-plan engine. Exports normalizeSchema() (handles both
.tableSchemas/.columns and .tables/.fields shapes, resolves primaryColumnId
→ primaryFieldId → first-col fallback), isComputedType() (full set of
non-writable field types), and snapshotBase() (async helper over
AirtableClient.getApplicationData). Pure logic, no mutations.

Three node:test assertions pass covering isComputedType flags and both
API shape variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure matchByName() builds tables/fields/choices maps from two snapshots.
saveIdmap/loadIdmap/savePlan/saveState persist per-pair state under
~/.airtable-user-mcp/sync/<srcId>__<destId>/ via safeAtomicWriteFileSync.
Two node:test suites pass (matching + round-trip I/O).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…f-stable)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…test

Extract computedSig() helper (description-free, ID-stable) so updateField
only includes changes.typeOptions for computed fields when the formula itself
genuinely differs — not when field IDs merely differ across bases.
Add two new tests: creation-ordering (scalars < links < computed) and the
computed-description-only diff guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…counts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tions for apply

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ary reconcile

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…kip unsupported

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…option

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ARHAEEM and others added 22 commits July 25, 2026 16:05
The verify loop waited up to 3 minutes per target. With one target that was
a 3-minute worst case; with eight it became 24 minutes of sleeping whenever
Open VSX's index stalled — and this check is warning-only, so it would burn
that time without ever failing the release.

Keep the full 3-minute budget for the first target, since that is what
actually covers Open VSX's indexing latency, then drop to 1 minute each once
any target has been confirmed — the rest land moments apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lumn-visibility predicate

Follow-up to the Task 10 review (853179e). Three Minor findings, all addressed:

F1 — auth._rawApiCall's JSON-branch CSRF injection spread `{...body, _csrf}`
unconditionally. A future array/string/number JSON body (none exists today,
but the surrounding comment promises the guarantee for "any future" caller)
would be silently coerced into an indexed object by the spread. Guarded to
plain objects only.

F2 — the exact predicate divergence just fixed in client.js was still live in
two sync sites reading the same raw columnOrder shape: sync/apply.js's
applyViewConfig columns facet (`c.visibility && refOk(...)`) and
sync/remap.js's canonicalizeViewConfig (`.filter(co => co.visibility)` /
`!co.visibility`). Both treated an absent `visibility` key as hidden, same as
the just-fixed client.js bug — a source column returned without the key would
never be shown on the destination (apply) and would compare as hidden instead
of visible (diff/compare), one module over from the fix already shipped.

F3 — extracted the predicate into a new tiny module,
`src/column-visibility.js` (`isColumnVisible`), since it's importable cleanly
from both client.js (top level) and sync/*.js (one level down) with no
circular dependency — client.js doesn't import sync/, and sync/apply.js takes
`client` as a parameter rather than importing client.js directly. All four
sites (client.js's getView + _showColumnsWithRetry, sync/apply.js's
applyViewConfig, sync/remap.js's canonicalizeViewConfig) now share one
definition and cannot re-diverge.

Checked test/sync/ for any test encoding the old (undefined-means-hidden)
behavior before changing the sync predicates: none exists — every existing
columnOrder fixture in test-compare-views.test.js and test-remap-view.test.js
sets `visibility: true`/`false` explicitly, never omits the key, so no
existing assertion needed to be touched (GC2 clean).

Regression tests added, each verified to fail without its fix by reverting
and re-running:
- test-auth-transport.test.js: an array JSON body sent through _rawApiCall
  comes back as `{"0":"a","1":"b","_csrf":...}` without the guard (F1).
- test-apply.test.js: a source columnOrder entry with no `visibility` key
  never reaches setViewColumns's visibleColumnIds without the fix (F2/apply).
- test-remap-view.test.js: a columnOrder entry with no `visibility` key
  canonicalizes into the `hidden` set instead of `visible` without the fix
  (F2/remap).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ame (review F1)

Review found a content-swap hole: replacing the BYTES of
impit-node.darwin-arm64.node with the linux-x64-gnu ELF while keeping the
filename passed every assertion cleanly. Only the printed size (6.1 -> 8.2
MiB) hinted at it.

Every check up to now inspected LABELS — package name, package.json
os/cpu/version, the filename the loader's "main" points at. A binary holding
another platform's machine code under the right filename satisfies all of
them. The magic number is the first check that reads the bytes themselves.

NATIVE_BINARY_MAGICS lives in vsix-targets.mjs and is keyed by the target's
own `os`, so it stays one source of truth and a new target cannot be added
without a magic mapping (expectedBinaryMagics throws for an unknown os).

The values were read from the 16 actual vendored binaries rather than
assumed: every win32 package starts 4d5a (PE "MZ"), every linux/alpine one
7f454c46 (ELF), every darwin one cffaedfe (Mach-O 64-bit MH_MAGIC_64). The
byte-swapped and universal/fat Mach-O magics are also accepted — we ship no
fat binary today (no target uses @ngrok/ngrok-darwin-universal) but one
would be legitimate.

This closes the swap hole and also catches a file truncated at the front.
The verified-binary output now prints each magic so the check is visible in
CI logs rather than silent.

Unreachable through our own pipeline, since the tarballs are byte-pinned to
pnpm-lock.yaml — but the assertion exists to catch a pipeline that has gone
wrong, so it should not have a hole.

Verified: the exact swap now fails with "starts with 0x7f454c46, which is
not a darwin binary"; all 8 real artifacts still pass, now reporting their
magic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… count (review F2, F3)

F2 — the previous bound only engaged AFTER a first success, so an index that
never catches up (queued publish, namespace problem) still slept
8 targets x 18 attempts x 10s ~= 24 minutes and emitted eight ::warning::
lines. And once the per-target budget dropped to 1 minute, the message still
claimed "within 3 minutes".

Replaced with a single wall-clock budget shared by all targets and enforced
whether or not anything has been confirmed yet: 180s total, checked against
`date +%s` rather than an attempt counter. Unverified targets are collected
and reported in ONE warning that states the actual elapsed seconds. This is
a best-effort check that never fails the release, so it must not dominate
the job's runtime.

Uses `if [ ... ]` rather than `(( ... ))` so a false arithmetic test cannot
interact badly with `set -e`. Simulated both paths: with nothing ever
indexing, 8 targets finish in one budget (7s against a 6s test budget, not
8x6s) and exit 0; with everything indexed, no warning is emitted.

F3 — `${#ARGS[@]}` reported 16 for 8 builds because each file pushes two
argv elements (`--packagePath` and the path). The published argv was always
correct; only the log line was wrong. Counted separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… stack

The top-level `await vendorPlatformPackagesForTarget(...)` surfaced every
failure as a raw unhandled rejection. The exit code was right and the
message was there, just buried above a stack trace — and these failures
(integrity mismatch, unreachable registry, missing lockfile entry) all carry
an actionable message that deserves to be the whole output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fix

Task 12 (documentation-accuracy pass, no logic changes):

- CLAUDE.md's "Keeping tool categories in sync" section overclaimed that
  LEGACY_CATEGORIES_DEFAULT_ON keeps a custom profile from silently
  widening to include "a tool" it never opted into. The allowlist is
  category-granular, not tool-name-granular: a new tool joining an
  already-legacy category (e.g. a future addition to record-write) still
  resolves an absent customTools key to enabled for standalone npm/CLI
  users. tool-config.js's ponytail comment and both CHANGELOGs already
  scoped this correctly (from eb87f60); CLAUDE.md was the one site that
  still overclaimed. Narrowed to name the residual gap, who's affected,
  and the remedy.

- Added the missing release note for 455bfda's column-visibility predicate
  unification: an absent `visibility` key on a source column previously
  compared as hidden in sync/apply.js and sync/remap.js, matching an
  explicitly-hidden dest column and reporting false convergence — masking
  real drift. Documented in both CHANGELOGs as a correctness fix (not a
  regression): a base pair previously reported converged/identical by
  `sync_base mode=diff` may now correctly surface column-visibility drift.

- Investigated whether any CHANGELOG/comment records autoNumber as a
  confirmed current bug in sync/apply.js (per owner: create and
  primary-retype already strip maxUsedAutoNumber, and the diff engine
  doesn't emit the suspected failing update). Found no such record in the
  tracked tree — the only matching claim lives on an external PR review
  thread, handled separately. No edit made.

Zero executable-code changes: CHANGELOG.md, CLAUDE.md, and
packages/mcp-server/CHANGELOG.md only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ic (18a/18b)

The VSIX assertion checked package name, package.json os/cpu/version, filename
and a 4-byte magic number. Only the magic reads the bytes, and it distinguishes
PE / ELF / Mach-O — the OS and nothing more. The cpu check beside it reads the
vendored package's OWN manifest, never the machine code. So two whole classes of
mis-vendored artifact passed clean, with a green tick:

  - x64 <-> ARM64 on the same OS (darwin-x64 bytes in the darwin-arm64 artifact)
  - glibc <-> musl              (linux-x64-gnu bytes in the alpine-x64 artifact)

Both were reproduced as controls and both passed under the previous check.

Every .node is now hashed and compared against scripts/native-binary-digests.json,
which records the exact SHA-256 of every binary each platform package ships.
record-native-binary-digests.mjs generates it by downloading each tarball and
refusing to hash anything inside until the tarball's SHA-512 byte-matches the
integrity hash pnpm-lock.yaml already recorded — so expected values derive from
the lockfile, never from the artifact under test. The FILE NAMES come from the
pin too, not from the artifact's own "main", closing the same circularity.

A mismatch names the impostor by reverse-lookup across all 16 pins ("these are
the bytes of impit-darwin-x64@0.14.3 — the binary for darwin-x64, not
darwin-arm64"). A missing or stale pin fails packaging and assertion CLOSED with
the regeneration command; it never degrades to the weaker check.

The magic check stays: it proves only the OS, but it turns the common case into
one readable sentence before the digest reports hex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s (18c)

Two ways a verified-once tree could go unverified afterwards.

1. Cache trust. .cache/vsix-platform-packages/<key> was integrity-checked at
   download and thereafter trusted via a .vendored.json marker — a file living
   INSIDE the directory it vouches for, so anything able to alter the binaries
   could equally rewrite the marker. Contents are now re-hashed against the
   pinned digests on every reuse (plus package.json name/version, plus a check
   for unpinned .node files); a copy that fails is discarded and re-fetched.
   The freshly-populated tree is validated the same way before the marker is
   written, so a bad tarball or a mismatched node_modules copy cannot be
   vendored either. The marker is now only a cosmetic source label.

   Verified: flipping one byte of a cached .node, leaving the marker intact,
   now re-fetches instead of silently vendoring the tampered binary.

2. Symlink escape. assertSafeSymlinks protected the workspace vendoring path
   but not the registry-tarball path — even though that path unpacks with
   `tar`, which creates whatever symlinks the archive asks for, and then copies
   with dereference:true. Both paths now share one guard, extracted to
   scripts/safe-symlinks.mjs so neither can drift from the other.

   Verified: an escaping link planted in a cache directory is refused by name
   and target; the same tree under the previous unguarded cpSync copied the
   linked file's contents straight into the vendored output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single-host workflow cannot runtime-load eight foreign native bindings. One
machine can only require() the binding compiled for itself, so ONLY the host
target's binding can receive a genuine runtime smoke; the other seven are
verified by exact content — which is the strongest claim available from one
host, and the reason the digest pinning in be862c3 matters.

Corrected in release.yml, README.md, CLAUDE.md and CHANGELOG.md, plus the
script docblocks and the log lines the run actually prints. Also records what
the previous check could and could not detect, so "the magic number reads the
bytes" is not read as "the magic number verifies the binary".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n (17)

Ownership criterion (b) of `_isOwnedCommandLine()` was a loose two-fragment
substring scan — `.airtable-user-mcp` followed by `.chrome-profile` — with no
executable requirement and no argument matching. ANY process whose command line
merely mentioned the profile path was force-killed with its whole process tree
by `forceStop()` (the dashboard Stop button and `airtable-formula.stopDaemon`):
an editor holding `…/.chrome-profile/Default/Preferences`, a `grep -r`/`rg` over
the config dir, a file manager, a backup job — and `…/.chrome-profile-backup` or
another app's Chrome on a different `--user-data-dir` matched just as readily.

This is the same defect criterion (a) was hardened against one branch above.
Criterion (b) now requires BOTH:

  1. the process image (argv[0] basename, `.exe` stripped) is a Chrome-family
     binary this project can actually launch — derived from patchright-core's
     channel + downloaded-browser registries (chrome/msedge/chromium,
     chrome-headless-shell, headless_shell, `Google Chrome for Testing`, the
     macOS app-bundle names and their helpers) and from browser-detect.ts
     (system chromium/chromium-browser, google-chrome*, microsoft-edge*, brave*);
  2. argv carries `--user-data-dir=<configDir>/.chrome-profile` for the EXACT
     resolved profile, matched as a whole argv token.

The token match reuses criterion (a)'s `_containsPathToken` boundary check
rather than a second hand-rolled predicate — a duplicated predicate is how (a)
and (b) drifted apart in the first place — so `-backup`/`.old` suffixes,
subdirectories, `/mnt/backup`-prefixed copies and another OS user's identically
named profile are all misses, under the same case-insensitive, `\` vs `/`
tolerant, quote-aware canonicalization. Bare, whole-argument-quoted and
value-quoted spellings are all understood, so a home directory with a space in
it still matches through POSIX `ps` output.

Fails safe exactly as (a) does: a process holding our exact profile that is not
an image we could have launched is left ALIVE and reported via `skippedUnowned`.

The docblock claimed criterion (b) was "already correctly scoped" and
"UNCHANGED from the original" — that claim was false and is why two review
passes accepted it. It is corrected in place.

Tests: daemon-manager 57 → 73 (16 new; every negative in both Windows and POSIX
command-line form). Reverting the predicate fails 11 of them, including the
editor case (3 editors force-killed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… packages (review F1)

Check 5 keyed on the expected PACKAGE, so a rogue `.node` added alongside the
pinned ones inside a legitimately-expected package was waved through: check 3
iterates the pinned filenames and never sees the extra, and check 5 saw only
that the package belonged. A genuine darwin-arm64 artifact with an extra
`impit-darwin-arm64/rogue.node` holding darwin-x64 machine code asserted clean
(exit 0). Node will load such a file if anything requires it by path.

Check 5 now requires each `.node` to be a PINNED FILENAME within an expected
package, so an unpinned binary fails by name with what the package is actually
allowed to ship.

`validateCacheDir` already refused unpinned `.node` files in the vendoring
cache, so our own pipeline could not emit one — but the artifact assertion is
the last gate before publish and must not be the weaker of the two.

Control NC8 added: exit 0 before, exit 1 after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (review F2/F3/F4)

F2 — `--check` compared the whole serialized file, so rewording `$comment`
reported "does not match the tarballs pnpm-lock.yaml pins": alarming and false.
It now compares a canonicalised `packages` object only, and names exactly what
differs (per package: version, integrity, per-file digest, added/removed
entries) instead of leaving a scavenger hunt across 16 packages.

F3 — the regenerate hint named only the raw node command; it now leads with
`pnpm record:binary-digests`. Both new scripts added to CLAUDE.md's Commands
block, which listed neither.

F4 — `findNodeBinaries` treats a symlinked `.node` as an ordinary file, so a
tarball could have pointed a pinned filename at a file outside the extraction
and had THAT hashed into the pin. Unreachable today (the integrity gate means
the tarball is byte-identical to what the registry published), but this routine
mints the trust root every later check depends on, so it no longer relies on an
upstream guarantee: assertSafeSymlinks now runs over the scratch extraction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two placements, one check, different audiences.

release.yml — a step between `pnpm install --frozen-lockfile` and packaging.
The first publish is far below it and the version bump/commit/tag lower still,
so a failure here ships nothing and tags nothing.

verify-binary-pins.yml — a `pull_request` job filtered to `pnpm-lock.yaml` and
`scripts/native-binary-digests.json`. A lockfile change is how an impit /
@ngrok/ngrok bump arrives, and without this the stale pin surfaces to whoever
runs the release rather than to the author who caused it. Its own file because
GitHub applies `paths` at the trigger level, and ci.yml's `pull_request` has
none. No `pnpm install` — the checker needs only Node builtins, the repo's own
scripts, the lockfile and `tar`.

Deliberately NOT in `pnpm test`: it needs the network and pulls ~150 MB.

Recorded in both files: what this uniquely catches is a pin file that is
internally consistent — right version, right integrity — but carries WRONG
DIGESTS. The neighbouring failure modes never reach it, being already covered
offline: a stale or missing pin fails closed in binaryPin() during vendoring,
and a re-published tarball fails the lockfile SHA-512 on download. The poisoned
pin file is the residual trust root, and only a fresh re-derivation exposes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not the gate

Trigger. Two files can invalidate the pin file WITHOUT editing it:
record-native-binary-digests.mjs decides how digests are produced, and
vsix-targets.mjs decides which packages are expected at all. Both added
alongside pnpm-lock.yaml and the pin file itself, each annotated with what it
changes.

Semantics. A path-filtered job that does not run reports as SKIPPED, and a
merge queue or required-check configuration can render that as green — so a
green PR here proves nothing about the pins. The header now says so outright,
names the release-workflow step as the actual safety boundary, and calls out
the two ways to get this wrong later: promoting this workflow to a required
check believing it gates merges (it passes by absence), or deleting the release
step believing this job covers it (it cannot).

Also records why the usual workarounds were declined — a companion
always-succeeds job is a permanently-green decoy, and dropping the filter costs
~150 MB of downloads on every unrelated PR — both of which only make sense if
this job were the gate, and it is not. The release step carries the mirror of
this note so the dependency reads in both directions.

Verified: gate at step 6, packaging at 12, first publish at 13, bump/tag at 17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…/F1)

Review found `_imageName()` more lenient than its docblocks: it took the
basename of everything before the first flag-shaped token, so the LAST pre-flag
positional decided. With our exact `--user-data-dir=<profile>` also present,
these were force-killed — `vim /home/u/chrome …`, `NOTEPAD.EXE C:\x\chrome …`,
`notepad.exe "C:\x\Google Chrome" …`, `node /home/u/tools/chrome …`,
`python3 /opt/x/chromium …` — while the docblocks asserted "argv[0]'s basename"
and "a filename — never a browser name". That over-claiming comment is the same
failure mode as the bug this task repaired, so the code is tightened to match
the claim rather than the claim relaxed to match the code.

`_imageName()` now resolves argv[0] properly: a quoted leading token verbatim;
otherwise the pre-flag head with a LEADING run of exec wrappers (env, nohup,
xvfb-run, dbus-run-session, setarch, stdbuf) skipped, after which the remainder
must plausibly be ONE path — no later token may look like a separate argument
(absolute/UNC/drive-rooted/`./`-relative path, quote or flag). That keeps
unquoted images containing spaces working (POSIX `ps` output, macOS app
bundles, `C:\Program Files\…`), keeps legitimate wrapper forms attributable,
and makes a browser-NAMED FILE handed to another program resolve to no image at
all. Anything ambiguous resolves to "not attributable": reported, never killed.

Also: the flag-cut regex now tolerates a quote before the dashes, so the bare
image + whole-argument-quoted flag shape (`chrome.exe "--user-data-dir=…"`) is
killed rather than merely reported (report §3/§6 had claimed this already
worked; it did not, and it is now pinned by a test).

Docs corrected in place, no new claims beyond what the code does: criterion
(b)'s conjunct 1 now spells out what "image" means and that an unpinnable
argv[0] fails safe; `_hasUserDataDirArg` records the known `=`-boundary
leniency (`--foo=--user-data-dir=<profile>` satisfies that conjunct, harmless
because the image conjunct still applies); and `CHROME_FAMILY_IMAGES` gains a
cross-reference stating that `mcp-server/src/process-tree.js` now differs
SUBSTANTIVELY (Windows-only image filter, none on POSIX, bare-dir pgrep
fallback) so neither is assumed to mirror the other.

Tests: daemon-manager 73 → 77, extension suite 165 → 169. The 4 additions fail
against the previous commit's implementation (2 negatives force-killing 3 and 5
processes; 2 positives under-killing), so they are load-bearing in both
directions. `process-tree.js` untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(21)

findProfileBrowserPids fell back to `pgrep -f -- <profileDir>` — ANY process
merely MENTIONING the profile path — whenever the `--user-data-dir=` marker
query found nothing, which is the NORMAL case (pgrep exits 1 on no match).
Its output went to a filter that dropped only `--type=` helpers, with no
executable filter on POSIX at all (win32 already had one), and straight into
terminateProcessTree. So on Linux/macOS `vim ~/.airtable-user-mcp/
.chrome-profile/Default/Preferences` was SIGTERMed then SIGKILLed with its
children — automatically, via evictProfileSquatters on profile-lock
acquisition and killProfileBrowserTree on every browser park/close.

Third instance of the same defect class (after _sweepOrphans criteria (a)
and (b) in daemon-manager.ts), so fix the class:

- POSIX now requires POSITIVE attribution on two independent conjuncts —
  the process image (argv[0] basename) is a Chrome-family binary this project
  can actually launch, AND argv carries `--user-data-dir=<our exact dir>` as
  a whole argv token. CHROME_FAMILY_IMAGES is derived from auth.js's channel
  use, patchright's channel + download registries, browser-detect.ts and
  browser-download.ts — not guessed.
- Whole-token matching via canon/containsPathToken (reimplemented from
  daemon-manager.ts with a cross-reference comment; the mcp-server package
  must not depend on the extension package). <profile>-backup, <profile>.old,
  <profile>/Default, /mnt/backup<profile> and another OS user's
  identically-named path all miss.
- Bare-directory fallback REMOVED. It is provably incapable of contributing
  a killable pid: it is a strict superset of the marker query, so everything
  it adds lacks the flag form and is rejected by conjunct 2. It existed for
  "Chromium variants that embed the path without the flag form", but no such
  variant exists — base::CommandLine only understands --switch=value, so
  a space-separated --user-data-dir does not claim the profile at all.
  pgrep is now candidate generation only; the filter is the authority (this
  also closes pgrep's ERE looseness, where `.` in .chrome-profile matched
  any character).
- Reject empty/relative userDataDir before executing anything.
- Anything unreadable or unattributable is left ALIVE — under-killing a
  genuine squatter beats killing a user's editor.

killProfileBrowserTree stays unguarded, deliberately: the pid source is now
narrow enough that its targets are our own orphaned browsers, both call
sites run under the scheduler barrier right after we closed our own context,
the acquisition path is already guarded by evictProfileSquatters, and adding
the daemon.lock check would silently disable orphan cleanup under
AIRTABLE_NO_DAEMON=1. Rationale and revisit-trigger recorded in the docblock.

win32 branch byte-for-byte unchanged (GC2) — it already carried the image
filter POSIX was missing; its residual substring near-miss is reported as a
follow-up, not shipped.

Tests: +49 (1038 -> 1087, all green). Negatives verified load-bearing —
reverting the source to HEAD fails 28 of the 58 process-tree tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ad (19)

auth.js's _rawApiCall called every non-array object "plain" and spread it
with _csrf, which silently destroys Date/Map/Set/RegExp/class instances
(e.g. {...new Date(), _csrf} loses the entire date, shipping {"_csrf":...}).

Replace with a real prototype-based isPlainObject(): only object literals
and Object.create(null) bags (prototype === Object.prototype or null) are
spread; everything else passes through byte-identical via plain
JSON.stringify. Comment corrected to state exactly what is/isn't covered,
including the deliberate cross-realm non-goal (no vm/iframe boundary in
this server). Added coverage for Date, Map, Set, a class instance, and
Object.create(null) as the positive case, asserting on the sent wire body.
…es (17/F3)

Round three of the same defect class: `NEW_ARG_LIKE` only recognised ROOTED
tokens, so a browser-named RELATIVE path handed to another program still
resolved to a Chrome-family image and was force-killed — `vim x/chrome`,
`node tools/chrome`, `python3 opt/x/chromium`, `NOTEPAD.EXE x\chrome`,
`git add sub/msedge`, each with our exact `--user-data-dir=<profile>`.

The root cause is not a missing pattern, it is that no rule over TOKENS can
work: `vim x/chrome` is byte-for-byte the same shape as the legitimate
`/applications/google chrome.app/contents/macos/google chrome`. So the rule is
now structural, and multi-token argv[0] is accepted ONLY when the path itself
proves its extent:

  1. quoted argv[0] → the launcher's quoting settles it (and, if it contains a
     space, it must be rooted — which also closes the fabricated
     `"vim /home/u/chrome"` shape the reviewer flagged as a minor);
  2. one token after skipping leading exec wrappers → that IS argv[0];
  3. more than one token → only with a ROOTED first token, no argument-shaped
     later token, AND the macOS bundle marker `.app/contents/macos/` in the
     join. Every Chrome-family image whose real path contains a space is a
     macOS bundle executable, so that marker is the whole legitimate set.

Deliberate narrowing, called out in the docblock and pinned by a test: an
UNQUOTED Windows image path containing spaces no longer attributes (reported,
not killed). Keeping it would mean accepting
`C:\Windows\System32\notepad.exe x\chrome.exe --user-data-dir=…` as a browser,
because the two are the same shape; every launcher that matters quotes an
argv[0] with spaces, so the quoted branch still covers Windows.

One residual is stated rather than implied away: a relative argument that
itself carries the bundle marker (`/usr/bin/vim x.app/contents/macos/google
chrome`) attributes. It is the one spelling indistinguishable from a real macOS
invocation, and it still needs our exact profile flag.

Also closes the `=`-boundary leniency instead of documenting it: the shared
`_containsPathToken` gained a `leadingBoundary` parameter, so criterion (a)
keeps `ARGV_BOUNDARY` (a path may follow `=`) while criterion (b) uses
`ARG_START_BOUNDARY` (a flag may not). That rejects `--foo=--user-data-dir=<p>`
and `--user-data-dir=/other=--user-data-dir=<p>` — the latter was a false kill
of a browser running a DIFFERENT profile.

Docblocks rewritten so every claim is one I falsified an attempt against: the
`NEW_ARG_LIKE` doc now says what it does NOT cover, `_imageName` enumerates its
rejections and its residual, and criterion (b) no longer says "can never".

Tests: 77 → 82. Two IMAGE CONJUNCT tests walk the whole space (bare relative,
nested relative, ./ and ../, absolute, rooted-program-plus-relative,
drive-relative, UNC, `\` and `/` mixes, multi-token argv, quoted fabrication,
ambiguous wrapper tails) in both platform forms; a third pins the legitimate
spellings that must still attribute; a fourth pins both nested-flag spellings.
All 4 additions fail against the previous commit (16 wrong kills in total).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…forced vector (22)

The "is this process ours, so we may force-kill it" predicate exists in two
implementations that cannot import each other (the MCP server is a standalone
npm package). It has drifted apart five times; each drift shipped a force-kill
of an innocent process, and each fix patched the reported example and missed a
neighbouring spelling. This closes the sixth and makes the seventh a test
failure instead of a bug report.

22a — port the fixed predicate to process-tree.js. Its `imageName` was still
the pre-fix logic: "everything before the first flag, take the basename", with
no exec-wrapper allowlist, no rooted/argument-shaped rejection and no
empty-on-ambiguity. So on POSIX these were SIGTERM/SIGKILLed with their
children whenever they carried our profile flag:
  vim x/chrome · node /home/u/tools/chrome · vim ./chrome ·
  vim /home/u/chrome · notepad.exe C:\x\chrome
Ported line-for-line from daemon-manager.ts (the reference, stabilised in
05b54aa): WRAPPER_IMAGES, ROOTED, NEW_ARG_LIKE, MAC_BUNDLE_MARKER,
CHROME_FAMILY_HELPER, the empty-on-ambiguity fail-safe, and ARG_START_BOUNDARY
for the flag (which also rejects the nested `--foo=--user-data-dir=<p>` and
`--user-data-dir=/other=--user-data-dir=<p>` spellings the old left boundary
accepted — the second is a browser on a DIFFERENT profile).

22b — the win32 residual. The CIM query ORed a bare
`.Contains($env:AIRTABLE_EVICT_DIR)` alongside the marker, so ANY root
chrome.exe/msedge.exe/chromium.exe whose command line merely MENTIONED the
profile path was taskkill /T /F'd: a user opening
file:///C:/Users/u/.airtable-user-mcp/.chrome-profile/Default/Preferences in
their everyday Chrome lost the whole browser and every tab, on this project's
primary platform. It was the same redundant widening removed from POSIX in
26cfa0f, and redundant for the same reason (the marker already IS
--user-data-dir=<dir>). Removed. PowerShell is now candidate generation only,
exactly like pgrep: it emits `<pid>\t<commandLine>` and the shared predicate
decides in JS, requiring the exact --user-data-dir=<dir> as a whole argv token.
The chrome/msedge/chromium name filter is kept; the marker is canonicalised so
the coarse filter is case/separator-insensitive like the predicate.

22c — the systemic fix. packages/shared/test-fixtures/process-attribution-cases.json:
124 command lines with expected verdicts, loaded and asserted by BOTH
test-process-tree.test.js and daemon-manager.test.ts. Editing one side and not
the other now fails a test on the side that was NOT edited — verified by
breaking each side in turn (see the task report). Implementation sharing was
considered and rejected: it would need an ESM→CJS build step across a package
boundary that must stay independent.

22d — smaller items.
  * The win32/pgrep marker interpolated the UNTRIMMED userDataDir while the
    filter used the trimmed one, so a padded path produced a no-match query.
  * profile-lock's `legacy` path is deleted: `pkill -f <profileDir>` killed
    every process whose command line matched the profile path as a REGEX, with
    no image filter, no argv-token matching and no ownership guard. Test-only,
    but it shipped in the published npm package one boolean from any caller.
  * terminateProcessTree gains an optional last-instant argv re-read to narrow
    the pid-recycling window (a recycled pid previously lost its own children
    to the pgrep -P walk). Deliberately three-valued — 'not-owned' aborts,
    'unknown' proceeds — so an unreadable argv behaves exactly as before and no
    new failure mode is introduced.

daemon-manager.ts is unchanged except for one docblock whose three claims about
process-tree.js this commit falsifies.

Tests: mcp-server 1092 -> 1231, extension 174 -> 300. No version bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (22/F1)

F1 — the win32 CIM query was missing `[Console]::OutputEncoding=[Text.Encoding]::UTF8`.
The reference sets it and documents why (daemon-manager.ts:_listProcesses). It was
immaterial here while only ASCII pids crossed stdout; commit 427a3c7 made the
command line itself round-trip through that stdout, so it stopped being immaterial
and became a fresh drift from the reference introduced by the commit whose purpose
was to end drift.

Windows PowerShell 5.1 writes redirected stdout in the console OEM codepage while
Node decodes UTF-8. Without the prologue a non-ASCII profile path
(C:\Users\Jose\... with an accent) arrives mojibaked, so:
  1. findProfileBrowserPids re-checks the mangled string, the --user-data-dir token
     no longer matches, and a REAL squatter is never evicted — the profile lock
     stays wedged and the browser cannot launch; and worse,
  2. makeOwnershipVerifier's re-read returns a NON-EMPTY mangled string, which is
     'not-owned' rather than 'unknown', so it does not merely fail to confirm an
     eviction — it ACTIVELY ABORTS one the selection pass already approved.
Both fail safe (never a false kill) but both are functional breakage on this
project's primary platform. Added to BOTH win32 PowerShell call sites.

Minor — maxBuffer. Node's default is 1 MB and overflow REJECTS the call, which for
an enumeration means "no candidates", i.e. the squatter is silently never evicted.
16 MB via one EXEC_MAX_BUFFER constant, matching the reference, applied to every
exec in the module (both win32 CIM calls, `pgrep -f`, `pgrep -P`, `ps`) so the two
enumeration paths cannot drift apart on it. Explicit `encoding: 'utf8'` too.

The shared fixture pins the PREDICATE and structurally cannot pin the
CANDIDATE-GENERATION QUERY — which is exactly where this drift got in. Five new
non-fixture tests close that gap: the prologue on both call sites, utf8+maxBuffer
on both, an end-to-end non-ASCII round-trip through selection AND verification, and
a regression-shape test documenting that a mojibaked re-read reads as 'not-owned'
(so removing the prologue is visibly a behaviour change, not a silent one).
Verified by deleting the prologue (2 failures) and the maxBuffer (1 failure).

process-tree.js + its tests only. Extension untouched. No version bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ker prove something (17/F5-F6)

Three holes, all the same failure mode as the ones before them — a rule fixed at
the position it was reported and left open one step over. Fixed in BOTH
implementations this time, and pinned in the shared vector so neither can drift.

F5 — the `=` boundary was fixed on the LEFT and left on the RIGHT. `=` is legal
inside a path on both platforms, so accepting it as a trailing boundary meant a
longer directory matched a shorter one:
  `--user-data-dir=<profile>=2`      → a browser on a DIFFERENT directory, killed
  `…/dist/mcp/index.mjs=1 daemon start` → a DIFFERENT file, killed (criterion a)
`ARG_BOUNDARY` (`[\s"']`) now governs both sides everywhere; the one asymmetry
left is criterion (a)'s leading side, which accepts `=` because a PATH may
legitimately follow it (`--script=<path>`) — named `PATH_LEAD_BOUNDARY` and
documented at both ends.

F6 — `MAC_BUNDLE_MARKER` was accepted platform-blind and proved nothing on its
own. Five non-browser shapes attributed, three of them Windows, where
`.app/Contents/MacOS` is not an executable layout at all. Two gates now:
  - the multi-token branch is refused for a Windows-SPELLED command line
    (decided from the raw string before canonicalisation folds `\` into `/`;
    spelling, not host platform, so the same command line attributes identically
    on every machine — which is what keeps the shared vector host-independent);
  - the join must be a bundle whose executable is named after it
    (`Google Chrome.app/…/Google Chrome`, `… Helper.app/…/… Helper (GPU)`), so
    `x.app/contents/macos/chrome` and `Foo.App\Contents\MacOS\Chrome` are out.

Minor — a quoted argv[0] was trusted whenever it was rooted, so
`"/usr/bin/vim /home/u/chrome"` and `"C:\Program Files\vim\vim.exe C:\x\chrome"`
still presented a browser basename. The `NEW_ARG_LIKE` sub-token rule already
used on the unquoted path now applies inside the quoted token too.

The residual is now POSIX-only and genuinely irreducible — a correctly-named
bundle path handed to another program — and it is PINNED in the fixture as
`residual-posix-named-bundle-arg` (expect `owned`, with the reasoning inline) so
the one known-bad shape cannot drift on either side unnoticed.

Docblocks: the three claims a reviewer falsified by probe are gone. "can never
satisfy it" now states which characters bound each side and why they differ;
"the one shape … IRREDUCIBLE" is now the POSIX-only shape with its reason; "the
guard costs nothing and removes the question" is replaced by what the guard
actually does and does not do.

Shared vector: +16 cases (124 → 140), appended, existing ids and ordering
untouched — 12 negatives for the shapes above, 3 positives guarding the
tightening (Edge bundle, named GPU helper, quoted bundle) and the pinned
residual. Reverting either implementation now fails 10 of them on that side.

extension 315 → 318 local + shared vector; mcp-server 1251 → 1252, both green.
`process-tree.js`'s two PowerShell call sites and the 22/F1 assertions are
untouched — no call site added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e same proof (17/F7-F8)

F7 — criterion (a) kept `=` on its leading boundary because "a path may
legitimately follow `=`". True in general, false for anything we emit:
`_spawnDetached()` and `daemon/launcher.js` both spawn
`[serverPath, 'daemon', 'start']`, so our entry is never an `=`-attached value.
The widening protected no real invocation and force-killed real tooling that
merely referenced our entry as a flag VALUE — `rsync --exclude=<entry>`,
`tar --exclude=<entry>`, `rg --file=<entry>`, `node --require=<entry>`,
`grep --include=<entry>`, `code --extensions-dir=<entry>`. `=` is now a boundary
on neither side, for neither criterion, and `_containsPathToken` has no
per-caller knob left to drift on.

The second half of the same defect: `_hasDaemonStartShape` was an ordered
`indexOf` scan, so `daemonize` satisfied `daemon` and `restart` satisfied
`start` — which is why the victims were backup and search tools rather than
exotica. It now matches WHOLE argv tokens.

That tightening would have silently narrowed REPORTING too (another install's
`--daemon start`, an `index.mjs.bak`), so reporting is now its own predicate,
`_looksLikeStrayDaemon` — deliberately generous because it never kills, but
requiring the `index.mjs` token to be a BARE argv entry, so none of the six
victims above are even named in the "left running" notice.

Criterion (a) still has NO image conjunct, and the docblock now says why rather
than leaving it implicit: (b) needs one because the browser profile is a SHARED
path, while (a)'s path is absolute and install-specific; after this fix I could
not construct a plausible victim; and the only derivable conjunct
(`basename(process.execPath)`) FAILS OPEN for the feature — an unanticipated
host binary would leave our own orphan unswept and the profile locked, which is
the bug this sweep exists to prevent.

F8 — the quoted-argv[0] guard rejected only ROOTED second sub-tokens, so the
bare-relative twins of the shapes it did reject sailed through:
`"/usr/bin/vim x/chrome"`, `"/usr/bin/git add sub/msedge"`,
`"/usr/bin/tar cf a x/google chrome"`, `"C:\Program Files\vim\vim.exe x\chrome.exe"`.
Quoting bounds where argv[0] ENDS; it does not make the interior one path, and
argv[0] is caller-chosen. A space-containing quoted token now clears the same
structural bar as the unquoted branch: POSIX spelling → a correctly-named macOS
bundle; Windows spelling → an executable extension only in the FINAL fragment
(`WIN_EXEC_EXT`), since a Windows path names one executable and names it last.

Shared vector: 140 → 147. The bare-relative quoted twins are pinned alongside
the rooted ones (the gap the reviewer noted made the vector read as covering a
class it did not), plus a positive guarding the Windows shape browsers actually
emit, plus the second residual — `"C:\x\vim x\chrome"`, where WIN_EXEC_EXT has
nothing to bite on — as `residual-win-quoted-two-relative-paths`. The existing
residual's "POSIX-only" wording is corrected to describe the SPELLING, not the
host, in both the fixture and the docblocks.

Docblocks: the four falsifiable claims are rewritten to what I probed —
"can never satisfy it", "the one shape … IRREDUCIBLE", "only a process naming
ITSELF that way" (argv[0] is caller-chosen), and NEW_ARG_LIKE's "closed by
ROOTED and bundle-marker" (which branch closes what, and that the regex closes
none of it alone).

extension 328 green, mcp-server 1259 green, tsc clean. Reverting either
implementation fails 8 of the new cases on that side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread .github/workflows/verify-binary-pins.yml Fixed
…o-install (24)

assertSafeSymlinks unconditionally realpathSync'd the workspace node_modules
root, so the advisory verify-binary-pins.yml job — which deliberately skips
`pnpm install` — threw ENOENT on a clean checkout and failed PR #19's
verify-pins check.

Replace the implicit global allowlist with an explicit policy every call site
must state: OWN_ROOT_ONLY (foreign tarball/cache trees and the digest
generator's scratch extraction — only the tree's own canonical root may be a
link target) vs ALLOW_WORKSPACE_NODE_MODULES (installed pnpm packages, where
the workspace node_modules root is additionally a legitimate target). A
missing node_modules under ALLOW_WORKSPACE_NODE_MODULES is now tolerated —
absence just means that allowance doesn't apply, not a crash.

Wire the correct policy into all four call sites (vendor-platform-packages.mjs
x2, prepare-package-deps.mjs, record-native-binary-digests.mjs) and add
scripts/safe-symlinks.test.mjs (node:test, 12 cases) covering both policies,
the missing-node_modules case, and genuine escape attempts under each policy
that must still be refused. Wired into `pnpm test` via a new `test:scripts`
script.

Also harden verify-binary-pins.yml: add `permissions: contents: read`, and
extend its path triggers to scripts/safe-symlinks.mjs,
scripts/vendor-platform-packages.mjs, and package.json so a future change to
the check itself re-runs the job. The advisory-not-gate framing in the
workflow's header comment is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread packages/mcp-server/src/sync/index.js
ARHAEEM and others added 3 commits July 26, 2026 00:00
Two reproduced force-kill false positives, both the same mistake: treating a
QUOTE as a generic argv boundary.

A. A value NESTED inside another argument satisfied the profile flag, because
   the nested value's own quotes are boundaries. All four of these returned
   OWNED from the real `isOwnedBrowserCommandLine`, and on win32 the eviction
   path would have `taskkill /T /F`ed the user's whole browser:

     chrome.exe --user-agent="--user-data-dir=<P>"      (a UA string)
     chrome.exe --proxy-pac-url="--user-data-dir=<P>"   (a PAC URL)
     chrome.exe --app="--user-data-dir=<P>"             (an app URL)
     /opt/google/chrome/chrome --user-agent="--user-data-dir=<P>"

B. `_hasDaemonStartShape` did `.split(/\s+/)` and stripped quotes AFTERWARDS,
   so `code.exe "<entry>" "daemon start"` — the user's editor with our bundled
   file open — yielded the tokens `daemon` and `start` and shape-matched: the
   editor and its process tree force-killed, taking unsaved work and the
   extension host running the sweep.

Both are replaced by a real quote-aware parser. `splitArgv`/`_splitArgv` splits
whitespace-separated entries, folds quoted sections into the entry they sit in
and removes their quotes, exactly as CommandLineToArgvW and POSIX shells do.
`argvEntryEnds`/`_argvEntryEnds` then requires the needle to BE an entry, and
returns the following index so criterion (a) can assert what comes next.

- `--user-data-dir=<P>` must be a whole argv entry. Bare, value-quoted and
  whole-argument-quoted spellings collapse to one comparison; a nested value is
  one entry whose text is `--user-agent=--user-data-dir=<P>` and is not it.
- criterion (a) requires the REAL emitted shape: the bundled entry, then the
  separate entries `daemon` and `start`, adjacent — what `_spawnDetached` and
  `launcher.js` build (`[serverPath, 'daemon', 'start', …]`). A single entry
  reading `daemon start` no longer qualifies. Path and subcommands are now
  checked together, so "mentioned here, `daemon`/`start` mentioned elsewhere"
  cannot combine into a kill.
- both readings of `'` are tried (quote, and literal). Real argv never quotes
  with `'`, and an account named O'Brien puts one inside our own profile path —
  reading it as a quote would silently break eviction for that user. A nested
  value fails both readings.
- "whitespace-only boundaries" is NOT the fix and is pinned against:
  `--user-agent="Mozilla/5.0 --user-data-dir=<P> Safari"` puts the flag
  whitespace-bounded inside a quoted value.
- an unquoted space-containing path (what `ps -o args=` emits) still matches,
  via a run join restricted to unquoted entries separated by one space.

`ARG_BOUNDARY`/`containsPathToken` are deleted on both sides.

Enumerated rather than guessed, because every previous round fixed the reported
spelling and missed its neighbour: 16 value-taking Chrome switches x 3 quote
styles x 2 path spellings, plus double nesting, escaped quotes, unbalanced
quotes, empty-value forms, the space-separated form, and profile paths
containing an apostrophe or a space. 150 command lines: the pre-fix code
answers 82 of them wrong, all false OWNED; the new code answers 0 wrong.

Shared fixture: cases 147 -> 174 (append-only, ids stable), plus three new
sections — `argvSplits` (17, pins the parser on both sides), `daemonEntryCases`
(14, criterion (a); the server asserts all of them not-owned, pinning the
deliberate site difference) and `neighbourEnumeration`, a committed generator
spec both suites expand into the same 108-line cross-product and assert
independently. That is the harness; it runs in `pnpm test`.

Evidence is PER-IMPLEMENTATION, deliberately. These two files are line-for-line
ports, so their agreeing proves mirror parity and nothing more — they agreed on
the wrong answer here for five rounds. Re-introducing defect A alone into
process-tree.js turns the server suite red (20 failures); re-introducing both
defects into daemon-manager.ts turns the extension suite red (25 failures,
including all three `"daemon start"`-as-one-argument cases).

mcp-server 1259 -> 1323, extension 328 -> 392. No assertion weakened; two
`.length` checks tightened to exact pids. One documented behaviour change:
`bash -c "chrome --user-data-dir=<P>"` is no longer REPORTED (it was never
killed) — under a real parse bash carries no such argument, and the chrome it
spawns is enumerated separately and still killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fingerprintSchema sorted tables and views before hashing but not fields, on
the same expression. Field-listing order isn't a stable API contract (and
CLAUDE.md already classifies field order as best-effort, non-drift), so a
field-order-only difference between plan-time and apply-time snapshots
flipped the fingerprint. The apply() drift guard treats that as a real
schema change: it hard-aborts a fresh apply (forcing a wasteful re-plan on
an unchanged schema) or, on a resume, emits a false RESUME_DRIFT_BYPASS
"someone else changed the dest" warning.

Sort the composed id=name=type strings the same way the views line already
does — equivalent to sorting by field id (unique, always the first token)
since ids can never collide, so order stops mattering while a genuine
rename/retype still changes the string content and therefore the hash.

Swept src/sync/ for sibling instances of the same pattern (identity hash
over an unsorted collection) — none found; every other join/sort site is
either already order-independent or deliberately order-sensitive for
best-effort order-drift reporting (compare.js).

Added regression coverage: same fields in different order -> same
fingerprint; add/remove/rename/retype a field -> different fingerprint.
Reverted the fix locally to confirm the new test fails without it, then
restored it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread packages/mcp-server/src/sync/index.js
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants